/*
  Copyright 2012 Sergey Ostanin

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
*/

package ru.irenproject;

import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;

import javax.inject.Inject;

import org.apache.commons.io.output.ByteArrayOutputStream;
import org.codehaus.jackson.JsonFactory;
import org.codehaus.jackson.JsonGenerator;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.JsonParser;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;

import ru.irenproject.lib.ProtobufToJsonConverter;
import ru.irenproject.lib.ProtobufToJsonHelper;
import ru.irenproject.store.Ids;
import ru.irenproject.store.Kind;
import ru.irenproject.store.PersistException;
import ru.irenproject.store.Store;
import ru.irenproject.store.StoreFactory;
import ru.irenproject.store.Thing;

import com.google.common.io.ByteStreams;
import com.google.common.io.LittleEndianDataInputStream;
import com.google.common.io.LittleEndianDataOutputStream;
import com.google.common.primitives.Ints;
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.protobuf.ByteString;
import com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.protobuf.Message;

public final class Main {
  private static final int MAX_REQUEST_LENGTH = 100 * 1024 * 1024;
  private static final String DELTA_CHANGED_FIELD = "changed";
  private static final String DELTA_DELETED_FIELD = "deleted";
  private static final String RESULT_FIELD = "result";
  private static final String SCENE_DELTA_FIELD = "sceneDelta";
  private static final ByteString FILE_PROCESSOR_CHANNEL_ID =
      Ids.fromAlienHexString("1f35e6303309d98fce28595877773de6");

  public static void main(String[] args) {
    Injector injector = Guice.createInjector(new MainModule());
    injector.getInstance(Main.class).run(System.in, System.out);
  }

  private final ProtoRegistry fProtoRegistry;
  private final ActionRegistry fActionRegistry;
  private final StoreCatalog fStoreCatalog;
  private final StoreFactory fStoreFactory;

  private final ObjectMapper fObjectMapper;
  private final JsonFactory fJsonFactory = new JsonFactory();

  @Inject Main(ProtoRegistry protoRegistry, ActionRegistry actionRegistry,
      StoreCatalog storeCatalog, StoreFactory storeFactory) {
    fProtoRegistry = protoRegistry;
    fActionRegistry = actionRegistry;
    fStoreCatalog = storeCatalog;
    fStoreFactory = storeFactory;

    fObjectMapper = new ObjectMapper();
    fObjectMapper.configure(JsonParser.Feature.INTERN_FIELD_NAMES, false);
  }

  private void run(InputStream in, OutputStream out) {
    try {
      registerFileProcessor();

      byte[] requestData;
      while ((requestData = readRequestData(in)) != null) {
        ByteArrayOutputStream response = new ByteArrayOutputStream();

        boolean ok;
        try (Request request = new Request(requestData)) {
          handleRequest(request, response);
          ok = true;
        } catch (BadInputException e) {
          e.printStackTrace();
          response.reset();
          ok = false;
        }

        writeResponse(response, ok, out);
      }
    } finally {
      closeStores();
    }
  }

  private void handleRequest(Request request, OutputStream response) {
    try {
      Store store = findStore(request.channelId());
      Session session = findSession(request.channelId(), store);

      try (JsonGenerator gen = fJsonFactory.createJsonGenerator(response)) {
        gen.writeStartObject();
        gen.writeFieldName(RESULT_FIELD);

        boolean applyChanges = false;
        store.beginActivity();
        try {
          Action action = performAction(request.targetId(), request.actionName(),
              request.contentReader(), store, session, gen);

          if (!action.forgetChanges()) {
            if (action.markAsModified()) {
              Thing root = store.getThing(Store.ROOT_ID);
              if (root instanceof MarkableAsModified) {
                ((MarkableAsModified) root).setModified(true);
              }
            }

            Set<ByteString> mustRender = store.dispatchEventsAndSave();

            store.getMarkedForRender(mustRender);
            session.render(mustRender);

            applyChanges = true;
          }
        } finally {
          store.endActivity(applyChanges);
        }

        gen.writeFieldName(SCENE_DELTA_FIELD);
        getShapes(session, gen);
        gen.writeEndObject();
      }
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  private Store findStore(ByteString id) {
    Store res = fStoreCatalog.get(id);
    if (res == null) {
      Path storeDir = Utils.getStoreDirectory(id);
      Check.input(Files.isDirectory(storeDir));
      res = fStoreFactory.create(storeDir);
      fStoreCatalog.put(id, res);
    }
    return res;
  }

  private Session findSession(ByteString id, Store store) {
    Session res = fStoreCatalog.getSession(id);
    if (res == null) {
      store.beginActivity();
      try {
        res = new Session(store, fProtoRegistry);
        res.render(Collections.<ByteString>emptySet());
      } finally {
        store.endActivity(false);
      }
      fStoreCatalog.putSession(id, res);
    }
    return res;
  }

  private void closeStores() {
    if (fStoreCatalog != null) {
      for (ByteString id : fStoreCatalog.idSet()) {
        Store store = fStoreCatalog.get(id);
        try {
          store.persist();
        } catch (PersistException e) {
          e.printStackTrace();
        }

        store.close();
      }
    }
  }

  private byte[] readRequestData(InputStream in) {
    try {
      Long requestLength;
      try {
        requestLength = new LittleEndianDataInputStream(in).readLong();
      } catch (EOFException e) {
        requestLength = null;
      }

      byte[] res;
      if (requestLength == null) {
        res = null;
      } else {
        Check.input(requestLength > 0);
        Check.input(requestLength <= MAX_REQUEST_LENGTH);

        res = new byte[Ints.checkedCast(requestLength)];
        try {
          ByteStreams.readFully(in, res);
        } catch (EOFException e) {
          throw new BadInputException(e);
        }
      }

      return res;
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  private void writeResponse(ByteArrayOutputStream response, boolean ok, OutputStream out) {
    try {
      LittleEndianDataOutputStream dataOut = new LittleEndianDataOutputStream(out);
      dataOut.writeLong(response.size() + 1);
      dataOut.writeBoolean(ok);
      response.writeTo(out);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  private void getShapes(Session session, JsonGenerator out) {
    try {
      SortedMap<ByteString, Message> changed = new TreeMap<>(Ids.COMPARATOR);
      SortedSet<ByteString> deleted = new TreeSet<>(Ids.COMPARATOR);
      session.getUnsentShapes(changed, deleted);

      out.writeStartObject();
      out.writeObjectFieldStart(DELTA_CHANGED_FIELD);

      ProtobufToJsonHelper helper = new Helper(fProtoRegistry);

      for (Map.Entry<ByteString, Message> e : changed.entrySet()) {
        out.writeFieldName(Ids.toHexString(e.getKey()));
        ProtobufToJsonConverter.print(e.getValue(), out, helper);
      }

      out.writeEndObject();
      out.writeArrayFieldStart(DELTA_DELETED_FIELD);

      for (ByteString id : deleted) {
        out.writeString(Ids.toHexString(id));
      }

      out.writeEndArray();
    } catch (IOException ex) {
      throw new RuntimeException(ex);
    }
  }

  private static final class Helper implements ProtobufToJsonHelper {
    final ProtoRegistry fProtoRegistry;

    Helper(ProtoRegistry protoRegistry) {
      fProtoRegistry = protoRegistry;
    }

    @Override public Map<String, String> getExtraFields(Message message) {
      Kind kind = fProtoRegistry.getShapeKind(message.getDescriptorForType());
      if (kind.value() == 0) {
        return Collections.emptyMap();
      } else {
        return Collections.singletonMap("kind", kind.toHexString());
      }
    }

    @Override public boolean isBase64Field(FieldDescriptor descriptor) {
      return descriptor.getOptions().getExtension(BaseProtos.shapeBase64);
    }
  }

  private Action performAction(ByteString targetId, String actionName, Reader in,
      Store store, Session session, JsonGenerator out) {
    try {
      Check.input(session.hasShapeId(targetId));

      Thing target = store.getThing(targetId);
      ActionDescriptor action = Check.inputNotNull(
          fActionRegistry.getActionDescriptorOrNull(target.getClass(), actionName));

      Object input;
      if (action.inputClass() == null) {
        input = null;
      } else {
        try {
          input = fObjectMapper.readValue(in, action.inputClass());
        } catch (JsonParseException | JsonMappingException e) {
          throw new BadInputException(e);
        }
      }

      Object actionResult;
      try {
        actionResult = (input == null) ? action.method().invoke(target)
            : action.method().invoke(target, input);
      } catch (InvocationTargetException e) {
        if (e.getCause() instanceof BadInputException) {
          throw (BadInputException) e.getCause();
        }
        for (Class<? extends Exception> badInputClass : action.annotation().badInput()) {
          if (badInputClass.isInstance(e.getCause())) {
            throw new BadInputException(e.getCause());
          }
        }
        throw e;
      }

      fObjectMapper.writeValue(out, actionResult);

      return action.annotation();
    } catch (IOException | InvocationTargetException | IllegalAccessException e) {
      throw new RuntimeException(e);
    }
  }

  private void registerFileProcessor() {
    Store store = fStoreFactory.create(null);
    fStoreCatalog.put(FILE_PROCESSOR_CHANNEL_ID, store);

    store.beginActivity();
    try {
      store.createWithId(FileProcessor.class, Store.ROOT_ID);
      store.dispatchEventsAndSave();
    } finally {
      store.endActivity(true);
    }
  }
}
